SolidJSに入門してみた
こんちには。
データアナリティクス事業本部機械学習チームの中村です。
今回は機械学習ではなくてフロントエンド話で、SolidJSのチュートリアルやってみた記事を書いてみたいと思います。
ちょっと前にフロントエンドを少しやっていたのですが、久しぶりに余暇を使って何か触りたいなと思い、SolidJSというものが目に入ったのがきっかけです。
なお本記事は以下のチュートリアルをやってみて、ココは結構Reactと違うなとか、個人的にハマった部分などフォーカスしています。
詳しく知りたい方はぜひチュートリアルをやってみてください!
環境セットアップ
お手元で動かしたい場合は、以下でOKです。
$ npx degit solidjs/templates/ts sample-solidjs $ cd sample-solidjs $ npm i $ npm run dev
バンドラがViteだからかもしれないですが、起動はちょ~~~~速いです。Reactでもおなじみのグルグルするやつが起動します。
カウンタサンプルを例に
useStateの代わりにcreateSignal
よくあるボタンを押下するとカウントが進むサンプルを見てみます。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <p>Count is: {count()}</p> </> ); };
App.tsxももちろん以下のように書き換えます。
import type { Component } from 'solid-js'; import { SampleCounter } from './SampleCounter'; const App: Component = () => { return ( <SampleCounter /> ); }; export default App;
画面はこんな感じです。
ほぼReactと同じような書き味ですが、以下が異なります。
- サンプルカウント数を保持するために、
createSignal
を使う。 createSignal
で得られるものは、getter関数とsetter関数であるため、値にはcount()
とカッコつきでアクセスする。
コンポーネントのレンダリングは1回だけ
以下のようにconsole.log
を仕込んでみます。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); console.log('rendered SampleCounter'); return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <p>Count is: {count()}</p> </> ); };
これを実行してもconsole.log
は1回しか実行されません。
つまり、コンポーネント内で以下のようにカウントを倍にする処理をいれても表示には反映されません。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); console.log('rendered SampleCounter'); const doubleCount = count() * 2 return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <p>Count is: {count()}, DoubleCount is: {doubleCount}</p> </> ); };
これを解決するためには、JSXの部分で直接count()
を参照するよう変更したり、
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); console.log('rendered SampleCounter'); return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <p>Count is: {count()}, DoubleCount is: {count() * 2}</p> </> ); };
もしくは、以下のように値を計算する別の関数を定義する方法があります。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); console.log('rendered SampleCounter'); const doubleCount = () => count() * 2; return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <p>Count is: {count()}, DoubleCount is: {doubleCount()}</p> </> ); };
これは、次のpropsの分割代入でReactivityが失われることと関連します。
分割代入をすると変更が伝搬しない
カウント表示部分を別のCompnentにしてみます。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <SubComponent value={count()}/> </> ); }; type SubComponentProps = { value: number; }; const SubComponent: Component<SubComponentProps> = ({value}) => { return ( <p>Count is: {value}</p> ); };
この書き方では、ボタンを押しても値が更新されません。
理由は({value})
の部分でpropsから値を取り出してしまっているためです。
この部分は、コンポーネントがレンダリングされる最初の1回のみでしか実行されません。
そのため、先ほどと同様以下のようにpropsを直接JSXで参照したり、
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <SubComponent value={count()}/> </> ); }; type SubComponentProps = { value: number; }; const SubComponent: Component<SubComponentProps> = (props) => { return ( <p>Count is: {props.value}</p> ); };
関数で包んであげたり必要があります。(この例は省略)
その他にもヘルパー(mergeProps
やsplitProps
)や、コストが掛かる計算にはcreateMemo
を使うのも手のようです。
詳細は以下を参照ください。
ちなみに、サブコンポーネントに渡すpropsをAccessor
のまま()
をつけずに渡せば、分割代入でも正しく動作させることができます。
import type { Component, Accessor } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <SubComponent value={count}/> </> ); }; type SubComponentProps = { value: Accessor<number>; }; const SubComponent: Component<SubComponentProps> = ({value}) => { return ( <p>Count is: {value()}</p> ); };
ただし公式でこのやり方は紹介されていません。
()
があったり無かったりで混乱を招きそう&ヘルパーが準備されているので、あまりこの方法はやらない方が良いのかもしれません。
useEffectの代わりにcreateEffect
副作用はcreateEffect
で書けます。
import type { Component } from 'solid-js'; import { createSignal, createEffect } from 'solid-js'; export const SampleCounter: Component = () => { const [count, setCount] = createSignal(0); createEffect(() => { console.log("The count is now", count()); }); return ( <> <button onClick={() => setCount(count() + 1)}>Click Me</button> <p>Count is: {count()}</p> </> ); };
依存するSignalは指定しなくても、埋め込まれたSignalを自動で読み込んでくれるようです。
ReactのuseEffectでは依存するstateを引数で指定する必要があったので、この点は便利そうですね。
リストサンプルを例に
制御フローというモノ
次はサンプルを変えて、以下のようなリスト表示するコンポーネントを題材にします。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleList: Component = () => { const [cats, setCats] = createSignal([ { id: 'J---aiyznGQ', name: 'Keyboard Cat' }, { id: 'z_AbfPXTKms', name: 'Maru' }, { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' } ]); return ( <> <ul> ... </ul> </> ); };
この...
の部分にcatsを埋め込みたいとします。Reactの場合はmapを使ってこんな感じにすると思います。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; export const SampleList: Component = () => { const [cats, setCats] = createSignal([ { id: 'J---aiyznGQ', name: 'Keyboard Cat' }, { id: 'z_AbfPXTKms', name: 'Maru' }, { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' } ]); return ( <> <ul> { cats().map( (cat, i) => <li> {i}: {cat.name} </li> )} </ul> </> ); };
これでもきちんと表示できています。
この場合、リストが変更されたとき、最レンダリングはどのようになるでしょうか?確かめてみます。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; import { JSX } from 'solid-js'; export const SampleList: Component = () => { const [cats, setCats] = createSignal([ { id: 'J---aiyznGQ', name: 'Keyboard Cat' }, { id: 'z_AbfPXTKms', name: 'Maru' }, { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' } ]); const changeListHandler: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = () => { setCats([ ...cats().slice(1, cats().length), cats()[0] ]); } return ( <> <button onClick={changeListHandler}>Click Me</button> <ul> { cats().map( (cat, i) => { console.log(`rendered ${i}: ${cat.name}`); return ( <li> {i}: {cat.name} </li> ); })} </ul> </> ); };
リストを変更する用のボタンと、レンダリング時に実行されるconsole.logを実装しました。
ボタンを押すたびに以下がコンソールに表示され、各アイテムが再レンダリングされていることがわかります。
rendered 0: Maru rendered 1: Henri The Existential Cat rendered 2: Keyboard Cat ...
SolodJSではこういった場合に再レンダリングが発生しないように、制御フローを使用することができます。
試しにFor
という制御フローを使用した例が以下です。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; import { JSX } from 'solid-js'; import { For } from 'solid-js'; export const SampleList: Component = () => { const [cats, setCats] = createSignal([ { id: 'J---aiyznGQ', name: 'Keyboard Cat' }, { id: 'z_AbfPXTKms', name: 'Maru' }, { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' } ]); const changeListHandler: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = () => { setCats([ ...cats().slice(1, cats().length), cats()[0] ]); } return ( <> <button onClick={changeListHandler}>Click Me</button> <ul> <For each={cats()}>{ (cat, i) => { console.log(`rendered ${i()}: ${cat.name}`); return ( <li> {i()}: {cat.name} </li> ); }}</For> </ul> </> ); };
ボタンを押下すると、console.logが初回のみとなり、再レンダリングが行われていないことが分かります。
ただし、配列インデックスi
がSignalのgetterになっていますので、i()
としてアクセスする必要がある点は注意が必要です。
あとFor
と似たものとしてIndex
という制御フローもあります。この違いについては長くなりそうなのでまた別途記事にしたいと思います。
テキスト入力サンプルを例に
onChangeではなくonInputを
またまた別のサンプルを準備します。
入力したテキストが即座に表示に反映されるよくやるアレです。
コードは以下のような感じです。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; import { JSX } from 'solid-js'; export const SampleTextInput: Component = () => { const [text, setText] = createSignal("default") const onChangeHandler: JSX.EventHandlerUnion<HTMLInputElement, Event> = (event) => { setText((event.target as HTMLInputElement).value) } return ( <> <input type="text" onChange={onChangeHandler} /> <p>Current input: {text()}</p> </> ); };
こんな感じで良いと思っていましたが、実際に動かすと、
Focusが外れるまで、Current input: の表示が古いままになってしまいました。
Reactに慣れていると、テキスト編集中(Focus中)の変更もonChange
で取得できていましたが、SolidJSの場合は以下のようにonInput
を使う必要があります。
import type { Component } from 'solid-js'; import { createSignal } from 'solid-js'; import { JSX } from 'solid-js'; export const SampleTextInput: Component = () => { const [text, setText] = createSignal("default") const onChangeHandler: JSX.EventHandlerUnion<HTMLInputElement, Event> = (event) => { setText((event.target as HTMLInputElement).value) } return ( <> <input type="text" onInput={onChangeHandler} /> <p>Current input: {text()}</p> </> ); };
これはどちらかというと、Reactが標準から外れているようでして、ReactではonChange
とonInput
は同じ動きになっています。(逆にFocusが外れたことは、onBlur
で取得する必要があります。)
その他
createSignalはどこにでも書ける
こちらはサンプルを準備できませんでしたが、Signalはコンポーネント内外問わずどこにでも記述することができます。(ReactのuseStateなどのHookはコンポーネント内に記述が必要でした)
これにより、Contextなどを使わなくてもグローバルな状態管理を簡単に書くことができます。 (実際には、SolidJSにもContextがあるので、状況に応じて使い分けが必要なようです)
まとめ
いかがでしたでしょうか?再レンダリングを抑制するのに凝れる作りで、いいなぁ~~という印象を受けました。
他にも便利な部分がたくさんあり紹介したい部分があったのですが、1本の記事にするには長くなりそうでしたのでここまでにしたいと思います。
For
とIndex
の違いやcreateStoreなども便利そうでしたので、また次回以降も紹介したいと思います。